Skip to content

fix(dbconn): keep the execution bounds where a pooler would drop them - #81

Draft
aparajon wants to merge 1 commit into
mainfrom
aparajon/pooled-session-timeouts
Draft

fix(dbconn): keep the execution bounds where a pooler would drop them#81
aparajon wants to merge 1 commit into
mainfrom
aparajon/pooled-session-timeouts

Conversation

@aparajon

@aparajon aparajon commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

lock_timeout and statement_timeout are how pg-sprite bounds every statement it issues (LK-2). They are session settings, and they were sent only as startup parameters — which a pooler is free to drop. PgBouncer drops anything in ignore_startup_parameters, in session mode as well as transaction mode, so the connection succeeds and the bounds are simply absent. Measured against a real PgBouncer: asked for lock_timeout=3s, statement_timeout=30s, got 0 and 0. Unbounded, reported as fine.

That matters beyond a misconfiguration: hosted PostgreSQL hands out a pooled connection string by default, so this is what an ordinary adopter gets.

Three steps, each catching what the one before it cannot:

  1  apply     startup params, plus a SET on every connection
               a pooler may drop a parameter; none strips a statement,
               so session-mode pooling now works correctly

  2  verify    read the bounds back off pg_settings
               refuse when the session did not keep them

  3  prove     take an advisory lock, hold a transaction open on a
               second connection so the first is forced to rebind,
               and refuse when the lock does not follow

Step 3 is not redundant. In transaction mode PgBouncer runs no server_reset_query by default, so the SET from step 1 sticks on a reused backend and step 2 reports healthy on a connection whose next statement lands on a backend that never saw it. Only forcing the rebind settles it.

The proof has no false positives — each reading is a fact about the connection in hand, not a guess about what sits behind it. It is one-sided the other way: an idle pooler with spare backends can still pass it, so it is a guard against the connection string a platform hands you, not a substitute for pointing pg-sprite at a session-mode endpoint. The refusal says which endpoint to use rather than failing opaquely.

LK-2: extends enforcement. The invariant text is unchanged — the bound was always required; this makes its absence detectable instead of silent.

The engine's lock_timeout and statement_timeout are session settings sent
as startup parameters. A pooler configured to ignore those parameters
connects successfully and simply does not have them, so the engine runs
unbounded while believing it is bounded — an ALTER that queues sits at
the head of the lock queue indefinitely.

Apply the bounds as SET statements on every new connection, which no
pooler strips, then verify the session actually holds them. Because an
idle transaction-mode pooler can hand the same backend back and report
the bounds present, also prove the connection keeps one session before
returning the pool: take an advisory lock, hold a transaction open on a
second connection so the first is forced to rebind, and refuse when the
lock does not follow.

This is what a hosted PostgreSQL default connection string looks like —
Supabase's is the transaction pooler — so the refusal names the
session-mode endpoint as the remedy rather than failing opaquely.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant